react 时间调度

March 24, 2021

前言

继续前面两篇 react 的分析。提到 react 16,除了 fiber 机制之外,还有其调度机制。这里就不得不提 requestIdleCallback 了,react 采用了 requestIdleCallback 的思想来实现调度,为什么说思想呢,因为 requestIdleCallback 是新出的 api,兼容性差,很多现代浏览器都不支持。于是 react 团队就写了一个 ployfill。

requestIdleCallback

关于 requestIdleCallback,请先看看 MDN 上面的介绍。requestIdleCallback 的出现意使得浏览器可以在空闲的时候执行 callback,这对单线程的 V8 而言就相当又用了。假设你需要大量涉及到 DOM 的操作的计算,在运算时,浏览器可能就会出现明显的卡顿行为,甚至不能进行任何操作,因为是单线程,就算用

在输入处理,给定帧渲染和合成之后,用户的主线程就会变得空闲,直到下一帧的开始,或者有优先的待处理任务,或者是存在输入。用户所看到的页面都是一帧一帧渲染出来的,下图中可以看到一帧的过程:

虽然上面是介绍 requestAnimationFrame 时用到的图片,但是也很详细的介绍到一帧里面浏览器都做了些什么,一帧里面除了上面干的活外就是空余时间 idle 了。可以看看 W3C requestidlecallback 的介绍:

Layout 和 paint 就是我们常见的重排和重绘,也就是 render 的部分,而 Task 就包含了各种任务,包括输入处理,js 处理等等。完成这些事情还有多少时间剩余呢?一般是按照 60fps 来处理的。至于为什么是 60fps 呢,大多数浏览器遵循 W3C 所建议的刷新频率也就是 60fps 了,requestAnimationFrame 里面就是按照频率来的。60 fps 就已经能够保证看到的动画不会一卡一卡了。所以在一帧 16.66ms 的时间里面空闲的时间就是 requestIdleCallback 调用处理的阶段了。

requestIdleCallback 参数

MDN 里面介绍到语法,这里再提一下:

var handle = window.requestIdleCallback(callback[, options]);

callback 是要执行的回调函数,会传入 deadline 对象作为参数,deadline 包含:

  1. timeRemaining:剩余时间,单位 ms,指的是该帧剩余时间。
  2. didTimeout:布尔型,true 表示该帧里面没有执行回调,超时了。

options 里面有个重要参数 timeout,如果给定 timeout,那到了时间,不管有没有剩余时间,都会立刻执行回调 callback。

React 中的实现

先看两张 amazing 的图: 首先是 React 16 之前版本的

在之前的版本里面,若 React 要开始更新的时候,就会处于深度调用的状态,程序会一直处理更新,而不会接受处理外部的输入。如果更新的层级多而深则会导致更新时间长的问题。到了 React 16 fiber 的阶段呢,如下所示;

可以明显的看到每次处理完一部分之后,react 都会从非常深的调用栈上看看有没有其他优先要做的事情,有则开始做其他事情,如输入事件等等,结束后再回过头来继续之前的事情。是不是很神奇!这种时间丝滑般的设计,对于大量数据的渲染很有帮助。看看 react 中设计的 requestIdleCallback ployfill。

const localRequestAnimationFrame = requestAnimationFrame;
// 链表头部与尾部
let  headOfPendingCallbacksLinkedList = null;
let  tailOfPendingCallbacksLinkedList = null;
// frameDeadlineObject 为传入callback的参数 deadline
const frameDeadlineObject = {
  didTimeout: false,
  timeRemaining() {
    // 通过 frameDeadline 来判断,该帧剩余时间
    const remaining = frameDeadline - now();
    return remaining > 0 ? remaining : 0;
  },
};
// export 对外函数,也就是 requestIdleCallback ployfill
scheduleWork = function(callback, options) {
  const timeoutTime = now() + options.timeout;
  const scheduledCallbackConfig: CallbackConfigType = {
    scheduledCallback: callback,
    timeoutTime,
    prev: null,
    next: null,
  };
  // 省略将scheduledCallbackConfig插入到链表里面过程
  if (!isAnimationFrameScheduled) {
    isAnimationFrameScheduled = true;
    localRequestAnimationFrame(animationTick);
  }
}
// requestAnimationFrame 调用函数
const animationTick = function(rafTime) {
  isAnimationFrameScheduled = false;
  // 更新 frameDeadline
  frameDeadline = rafTime + activeFrameTime;
  if (!isIdleScheduled) {
    isIdleScheduled = true;
    window.postMessage(messageKey, '*');
  }
}
// 省略消息监听处理部分

// 执行 callback,与传参 deadline
const callUnsafely = function(callbackConfig, arg) {
  const callback = callbackConfig.scheduledCallback;
  callback(arg);
  // 总是会删除调用过的 callbackConfig
  cancelScheduledWork(callbackConfig);
}

cancelScheduledWork = function(callbackConfig) {
  // 在链表中删除对应节点,并维护好pre以及next关系
}

可以看出这里是采用 requestAnimationFrame 来代替 requestIdleCallback,这也很好理解。关键地方在于 deadline 参数的传递。这里用了 frameDeadlineObject 来表示,每次 requestAnimationFrame 的时候都会更新 frameDeadlineObject 对象里面的 frameDeadline 基线。frameDeadline 正如其名,就是每次 requestAnimationFrame 开始的时间以及该帧时长之和。只要 now() 的时间大于它,自然是表示没有空闲时间。

比如在一个帧里面,可能第一个 callback 运行时间过长,frameDeadline - now() 不为正数,则不会无法继续执行。程序会在下一帧开始的时候执行 input 事件什么的,若有空闲时间才执行 idle callback。

上文中通过链表的结构,每次都将传入的 callback 和 timeoutTime 保存起来,以 pre/next 的形式来维系。并在 callUnsafely 里面调用完之后就删除掉。回顾一下上面处理流程,通过 scheduleWork 传入 callback,用 requestAnimationFrame 方式第一次启用 animationTick,并用事件的方式 window.postMessage 消息的方式来调用。其中省略的消息监听的处理如下:

// messageKey 为特点字符串
const idleTick = function(event) {
  if (event.source !== window || event.data !== messageKey) {
    return;
  }
  isIdleScheduled = false;
  callTimedOutCallbacks();
  let currentTime = now();
  // 空闲时间判断
  while (
    frameDeadline - currentTime > 0 &&
    headOfPendingCallbacksLinkedList !== null
  ) {
    const latestCallbackConfig = headOfPendingCallbacksLinkedList;
    frameDeadlineObject.didTimeout = false;
    callUnsafely(latestCallbackConfig, frameDeadlineObject);
    currentTime = now();
  }
  // 继续下一个节点,调用requestAnimationFrame
  if (
    !isAnimationFrameScheduled &&
    headOfPendingCallbacksLinkedList !== null
  ) {
    isAnimationFrameScheduled = true;
    localRequestAnimationFrame(animationTick);
  }
}
window.addEventListener('message', idleTick, false);
// 如果设置了 timeoutTime 的话,自然是无脑执行到底的,而不会把时间让渡予下一帧
const callTimedOutCallbacks = function() {
  const currentTime = now();
  const timedOutCallbacks = [];
  let currentCallbackConfig = headOfPendingCallbacksLinkedList;
  while (currentCallbackConfig !== null) {
    if (timeoutTime !== -1 && timeoutTime <= currentTime) {
      timedOutCallbacks.push(currentCallbackConfig);
    }
  }
  // 存在 timeoutTime 的事件,并且发生超时了,那就执行,不考虑帧的问题了
  if (timedOutCallbacks.length > 0) {
    frameDeadlineObject.didTimeout = true;
    for (let i = 0, len = timedOutCallbacks.length; i < len; i++) {
      callUnsafely(timedOutCallbacks[i], frameDeadlineObject);
    }
  }
}

animationTick 结合 idleTick 形成消息传递事件的发送方和接收方,同时也分别是 requestAnimationFrame 回调函数和触发函数。通过 messageKey 来识别是否是通知的自己。idleTick 里面的循环判断和 timeRemaining 相同,判断是否有空闲时间,有才进行 callUnsafely,执行 callback。

fiber 与 requestIdleCallback

在上面的代码好像都没有看到 timeRemaining 使用的地方哦,其实在 workLoop 里面才会有判断

function workLoop(isYieldy) {
  if(isYield) {
    while (nextUnitOfWork !== null && !shouldYield()) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  }
}
function shouldYield() {
  if (deadline === null || deadlineDidExpire) {
    return false;
  }
  if (deadline.timeRemaining() > 1) {
    return false;
  }
  deadlineDidExpire = true;
  return true;
}

对于异步更新的每次执行 performUnitOfWork 前都会判断一次是否有空余时间,有才会继续。通过这个地方判断是否有空闲时间。

前者 idleTick 里面是在初始化的时候判断,能否立马执行 performWork 函数,以及在该帧里面能够执行链表的下一个 performWork。 后则 workLoop 是在 render/reconcilation 阶段的 workLoop 循环里面判断空闲时间,有就继续。当然在第二个阶段 commit 是没有检查空闲时间过程的。从而实现了之前版本没有现实的方式。当一次组件更新时间较长的时候,仍然运行 input 等操作,同时,更重要的是不会发生局部更新。事件能打断的只是 render/reconcilation 阶段,这个阶段不会发生任何的真实 DOM 的变化。这也是调度里面最神奇的地方,空闲时间的检查仅仅发生在 render/reconcilation 阶段。

只是该阶段还是会执行 ComponentWillUpdate 这些生命钩子,well,react 团队表示着没有关系。

于是到了timeRemaining之后,render/reconcilation 阶段就会被打断,继续处理浏览器的其他输入事件。并在输入后执行

expirationTime 与优先级别

之前我们计算 expirationTime,都是按照同步来算的,也就是值为 1(SYNC)。既然是同步自然就不需要调度来控制任务系统了。当 expirationTime !== SYNC 的时候,才进入 requestIdleCallback 的任务调度

function requestWork(root, expirationTime) {
  if (expirationTime === Sync) {
    performSyncWork();
  } else {
    // 进入调度
    scheduleCallbackWithExpirationTime(expirationTime);
  }
}

这里的 expirationTime 是 root.expirationTime。也就是说 React 当前是处于同步还是调度模式,是由 root 的 expirationTime 决定的。这也就是说明了其模式分两种,一种是同步,一种是调度。而调度的优先级将取决于 expirationTime。

##总结 本文更多的只是结合 requestIdleCallback 来介绍异步相关过程,但是更多的内容还是没有介绍到,将放在下篇文章 react 开启异步渲染与优先级 介绍到。

参考

  1. W3C requestidlecallback
  2. requestAnimationFrame Scheduling For Nerds